Skip to content

jit.profile and jit.allocprof: small hardenings and improvements - #663

Closed
damiansirbu wants to merge 7 commits into
themrdemonized:prfrom
damiansirbu:fix-jit-profile-stop-callback
Closed

jit.profile and jit.allocprof: small hardenings and improvements#663
damiansirbu wants to merge 7 commits into
themrdemonized:prfrom
damiansirbu:fix-jit-profile-stop-callback

Conversation

@damiansirbu

Copy link
Copy Markdown
Contributor

Summary

Robustness improvements to the jit.profile / jit.allocprof sampler from #641.
The profile callback recovers on its own and sampling continues uninterrupted.
The stop path joins the sampler thread before clearing its state, keeping shutdown and the timer thread cleanly separated.

Callback recovery keeps the session running

The profile callback runs under lua_pcall in jit_profile_callback (lib_jit.c).
Upstream LuaJIT hands a non-zero pcall status to the panic handler and then calls exit(EXIT_FAILURE), which suits the luajit CLI but is heavy for a game.
In this fork the panic handler already logs and returns.
CScriptEngine::lua_panic (script_engine.cpp:179) prints the stack and output, then returns 0.
The improvement leans on that: it drops the exit, clears the callback thread stack with lua_settop(L2, 0) so nothing accumulates across samples, and sampling carries on.
This matters most at the LuaJIT 2.0.4 32-bit allocation ceiling during a long capture, where an out-of-memory raise in the callback is absorbed and the run continues.

Timer thread joined before its state is cleared

On stop, luaJIT_profile_stop (lj_profile.c) cleared ps->g = NULL and then stopped the timer.
The Windows sampler runs on its own thread, which reads ps->g inside profile_trigger and dereferences it (g->hookmask, lj_profile.c:163-167).
Joining that thread before clearing its state keeps the two from overlapping on ps->g.
The stop now joins the sampler thread first, setting the abort flag and waiting on it (WaitForSingleObject(ps->thread, INFINITE), lj_profile.c:287-290).
ps->g clears once the thread has exited, so the timer path always reads a live g.

Files changed

  • src/3rd party/luajit-2/src/lib_jit.c (callback recovery, +2/-1)
  • src/3rd party/luajit-2/src/lj_profile.c (timer-stop ordering, +1/-1)

Testing

Built locally (DX11, 0 errors) and run on a live GAMMA session with both profilers compiled in.
For the callback improvement, a probe armed a callback that raised every 10ms for about 6 seconds, roughly 600 raises, then stopped it.
The game stayed alive throughout and kept logging after the probe cleared the callback.
The timer improvement follows from the source above: the deref is direct, and joining first keeps the clear ordered after the thread exits.
The sampler ran a full session with a clean stop.

@damiansirbu
damiansirbu marked this pull request as draft September 9, 2026 16:57
@damiansirbu

Copy link
Copy Markdown
Contributor Author

Draft while I fold in additional hardening surfaced by large-modpack validation.
GAMMA-scale allocation captures showed that jit.allocprof stack keys can truncate mid-frame on deep call chains, corrupting attribution of the deepest frame.
The fix truncates at frame boundaries instead, alongside a buffer and depth sizing pass.
Ready flag returns once in-game captures confirm clean stacks.

@damiansirbu

damiansirbu commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

A few more commits on top, all from running this as the backend of an in-game profiling UI on a full GAMMA install (600+ mods).

Buffer sizing.
The dumpstack buffer went 2048 to 8192 and the allocprof stack key 512 to 4096.
The deepest stack I have captured in the field is 29 frames, and 2048 could truncate it mid-frame.
A stack that still overflows now cuts at the last ;, so a dropped frame drops whole and truncation can never invent a function name.

Interval jitter.
A fixed 5ms sampler wakeup can phase-lock with per-frame work and with the 100/200ms throttles that many modpack scripts run on, and then it over- or under-samples them.
The timer thread now sleeps an exponential draw around the configured mean, the same distribution the alloc sampler already uses for its byte distance.
The mean rate is unchanged at about 200 samples per second at i5, measured as 22549 samples over a 127s fight.

Heap-allocated tables.
After the key bump the allocprof aggregation tables reached 80MB of static image data.
They now allocate with calloc on the first jit.allocprof.start, so a process that never profiles holds none of it.
They persist past stop on purpose, because dumps read them after stop.

GC state getter, jit.util.gcstat.
The alloc profiler measures allocated bytes, but not GC schedule health, the collector's distance to its next step (threshold) and how far behind it runs (debt).
Those fields live in GCState and were not readable from Lua, and collectgarbage("count") exposes only total.
jit.util.gcstat() returns a table of total, threshold, estimate, and debt in bytes, mirroring the shape of jit.util.traceinfo.
It is a pure read of engine state and computes nothing, so it changes no VM behaviour.
It reads a coherent snapshot, copying the four fields to locals before allocating the result table, because that allocation can itself run a GC step and move them.
I added the matching manifest entry to lua_help_ex.script.

Test coverage.
Several hours across many sessions, at least 2 of them pure profiling.
The latest round ran 2-minute combat captures on a vanilla-based install and on full GAMMA, on both the single-threaded and multi-threaded exes.
Both showed 0% of samples at the depth-64 cap and a VM-state split that reconciles with the per-stack weights.
I verified gcstat on both variants, with threshold at or above total, estimate at or below total, debt bounded, and the values tracking allocation and collection across a fight.
The stop path has been through many start and stop cycles with the timer-thread join in place.

@damiansirbu
damiansirbu marked this pull request as ready for review September 10, 2026 13:27
@damiansirbu damiansirbu changed the title Harden the jit.profile sampler: callback recovery and timer-stop ordering jit.profile and jit.allocprof: small hardenings and improvements Sep 10, 2026
jit_profile_callback ran exit(EXIT_FAILURE) after a failed lua_pcall, so
one error in the sampling callback killed the whole process. The engine
panic handler (CScriptEngine::lua_panic) already logs the stack and
returns, so the error stays visible. Drop the exit, clear the callback
thread stack, and keep profiling. The failed sample is dropped.
luaJIT_profile_stop cleared ps->g before profile_timer_stop, so the Windows
timer thread could enter profile_trigger, read the now-null g, and
dereference it. Join the timer thread first, then clear the state. No
trigger runs after the join.
…frames whole

allocprof_record cut the stack key mid-frame at the buffer bound, so a truncated
frame read as an unattributable stack and lost bytes to "unknown". Cut at the last
';' instead, so a partial frame drops whole.

Raise the CPU dumpstack buffer 2048->8192 and the alloc stack key 512->4096 so a
64-deep capture holds the whole stack without mid-frame truncation.
The CPU sampler slept a fixed interval, so a mod running on a fixed schedule could
phase-lock to it and be systematically over- or under-sampled. Draw each sleep from an
exponential around the mean, the same way the allocation profiler already draws its
sample distance, so the sample times are never periodic.
The leaf+stack aggregation tables were 80MB of static image data
(4096+16384 slots x 4104B) after the key-size increase. Allocate them
with calloc on the first jit.allocprof.start instead: a process that
never profiles holds no table memory. The tables stay alive after stop
because consumers dump after allocprof.stop(); reset and slot reads are
NULL-guarded for calls before the first start.
The allocation profiler measures bytes but cannot show GC schedule health:
how close the collector is to its next step and how far behind it runs.
Those live in GCState (threshold, estimate, debt) and were not Lua-readable;
collectgarbage("count") exposes only total.

Add jit.util.gcstat() returning {total, threshold, estimate, debt} in bytes,
mirroring jit.util.traceinfo's table shape. Double-encoded, so an MSize past
2 GB does not wrap the int32 field. Read-only: it exposes engine state and
computes nothing, so it never changes VM behaviour. Manifest entry added.
The profiler can show GC time share but not how often the collector runs.
Add a monotonic cycle counter incremented at both GCSpause end-transitions
in gc_onestep (the single chokepoint every collection passes through, so it
catches allocation-driven, parallel, and explicit collects alike), and
return it as a fifth gcstat field. The counter is a file-scope global in
lj_gc.c, not a GCState field, so it does not shift global_State layout or the
save format.
@damiansirbu

Copy link
Copy Markdown
Contributor Author

Added a GC-cycle counter to jit.util.gcstat.

gcstat reported the live heap counters but not how often the collector runs, and that frequency is what a memory profiler needs when tracking GC stutter.
gcstat now returns a fifth field, cycles. It is the count of completed GC cycles since VM start.

Mechanism: the counter increments at the two GCSpause end-transitions in gc_onestep (lj_gc.c), the single point every collection passes through.
That catches allocation-driven, parallel, and explicit collections alike, and it lives at lj_gc.c file scope, leaving global_State layout and the save format untouched.

Lua usage:

local gc = jit.util.gcstat()
-- gc.total, gc.threshold, gc.estimate, gc.debt (bytes); gc.cycles (count)

A tool samples cycles over time for a collections-per-second rate.

Tested:

  • Single-thread: DX11 built clean (0 errors) on this branch.
  • Multi-thread: cherry-picked onto the MT branch, DX11 built clean, deployed to GAMMA Redux and vanilla Anomaly. Both boot and gcstat().cycles climbs in play (~400 collections, ~3/s).

One caution for anyone adding a counter to the VM structs: an earlier build put the field in GCState and crashed at startup.
A wider GCState shifts every global_State member after it against the assembled interpreter offsets, so a file-scope global avoids it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants